| 1 | /** |
| 2 | * Static Open Graph image endpoint. During `astro build` Astro invokes this |
| 3 | * for every entry returned by `getStaticPaths`, writing a WebP into |
| 4 | * `dist/og/<slug>.webp`. Pages reference these via `<meta property="og:image">` |
| 5 | * in their layout/head. |
| 6 | * |
| 7 | * - Marketing pages (top-level `src/pages/*.{astro,mdx}`) → /og/<page>.webp |
| 8 | * (the homepage is keyed as `index`). |
| 9 | * - Starlight docs (`getCollection("docs")`) → /og/<entry.slug>.webp. |
| 10 | * |
| 11 | * The card itself lives in `src/brand/OgCard.tsx` and is rendered via |
| 12 | * Takumi. Fonts are the same families used on the website |
| 13 | * (`tokens.css`), loaded from npm bundles with full Source Serif glyph coverage — |
| 14 | * arrows, em-dashes, fancy quotes, etc. all render verbatim. |
| 15 | */ |
| 16 | |
| 17 | import type { APIRoute, GetStaticPaths } from "astro"; |
| 18 | import { getCollection } from "astro:content"; |
| 19 | import { createHash } from "node:crypto"; |
| 20 | import type { ReactNode } from "react"; |
| 21 | import { renderToStaticMarkup } from "react-dom/server"; |
| 22 | import { jsx } from "react/jsx-runtime"; |
| 23 | import { render } from "takumi-js"; |
| 24 | import { brandFonts } from "../../brand/fonts.ts"; |
| 25 | import { |
| 26 | OgCard, |
| 27 | type OgCardKind, |
| 28 | type OgCardProps, |
| 29 | } from "../../brand/OgCard.tsx"; |
| 30 | |
| 31 | interface Entry extends OgCardProps { |
| 32 | slug: string; |
| 33 | } |
| 34 | |
| 35 | const RENDER_CONCURRENCY = 4; |
| 36 | |
| 37 | const renderOptions = { |
| 38 | width: 1200 * 2, |
| 39 | height: 630 * 2, |
| 40 | devicePixelRatio: 2, |
| 41 | format: "webp" as const, |
| 42 | emoji: "from-font" as const, |
| 43 | }; |
| 44 | |
| 45 | async function renderCard(entry: Entry): Promise<Uint8Array<ArrayBuffer>> { |
| 46 | return render(OgCard(entry), { ...renderOptions, fonts: brandFonts }); |
| 47 | } |
| 48 | |
| 49 | /** |
| 50 | * Astro's incremental builder generates routes serially, so a cold build would |
| 51 | * otherwise leave all but one native renderer idle. Keep a bounded window of |
| 52 | * upcoming cards in flight: cache hits do no rendering at all, while cold builds |
| 53 | * still use concurrent native rendering. Entries outside the window are never speculatively |
| 54 | * rendered. |
| 55 | */ |
| 56 | let renderEntries: Entry[] = []; |
| 57 | const renderEntryIndexes = new Map<string, number>(); |
| 58 | const pendingRenders = new Map<string, Promise<Uint8Array<ArrayBuffer>>>(); |
| 59 | |
| 60 | function setRenderEntries(entries: Entry[]) { |
| 61 | renderEntries = entries; |
| 62 | renderEntryIndexes.clear(); |
| 63 | for (const [index, entry] of entries.entries()) { |
| 64 | renderEntryIndexes.set(entry.slug, index); |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | function renderWithLookahead(entry: Entry) { |
| 69 | const start = renderEntryIndexes.get(entry.slug); |
| 70 | if (start === undefined) return renderCard(entry); |
| 71 | |
| 72 | const size = import.meta.env.DEV ? 1 : RENDER_CONCURRENCY; |
| 73 | for (const candidate of renderEntries.slice(start, start + size)) { |
| 74 | if (pendingRenders.has(candidate.slug)) continue; |
| 75 | const render = renderCard(candidate); |
| 76 | render.catch(() => {}); |
| 77 | pendingRenders.set(candidate.slug, render); |
| 78 | } |
| 79 | |
| 80 | return pendingRenders.get(entry.slug)!; |
| 81 | } |
| 82 | |
| 83 | // ──────────────────────────────────────────────────────────────────────────── |
| 84 | // Page enumeration |
| 85 | // ──────────────────────────────────────────────────────────────────────────── |
| 86 | |
| 87 | /** |
| 88 | * Fallbacks for the marketing pages — these aren't in a content collection |
| 89 | * so we hand-curate their OG metadata. Keys are URL-style slugs (e.g. |
| 90 | * `index` for `/`). |
| 91 | */ |
| 92 | const MARKETING_PAGES: Record<string, Omit<Entry, "slug" | "kind">> = { |
| 93 | index: { |
| 94 | title: jsx("span", { |
| 95 | children: [ |
| 96 | "Reach for the ", |
| 97 | jsx("span", { |
| 98 | style: { fontStyle: "italic", color: "#a8c47a" }, |
| 99 | children: "better primitives.", |
| 100 | }), |
| 101 | ], |
| 102 | }), |
| 103 | description: |
| 104 | "Snap together cloud resources and let Alchemy take care of the wiring so you can focus on your product.", |
| 105 | eyebrow: "infrastructure as effects", |
| 106 | }, |
| 107 | compare: { |
| 108 | title: "Alchemy vs SST, Pulumi & Terraform", |
| 109 | description: |
| 110 | "One TypeScript program for your infrastructure and the code that runs on it, with no wrapped engines underneath.", |
| 111 | eyebrow: "compare", |
| 112 | }, |
| 113 | privacy: { |
| 114 | title: "Privacy & Telemetry", |
| 115 | description: |
| 116 | "What data the Alchemy CLI and Cloudflare State Store collect, where it goes, and how to opt out.", |
| 117 | eyebrow: "alchemy.run", |
| 118 | }, |
| 119 | }; |
| 120 | |
| 121 | function classifyDoc(slug: string): { kind: OgCardKind; eyebrow: string } { |
| 122 | if (slug.startsWith("blog/")) |
| 123 | return { kind: "blog", eyebrow: "blog · alchemy.run" }; |
| 124 | if (slug.startsWith("guides/")) |
| 125 | return { kind: "doc", eyebrow: "guide · alchemy" }; |
| 126 | if (slug.startsWith("concepts/")) |
| 127 | return { kind: "doc", eyebrow: "concept · alchemy" }; |
| 128 | if (slug.startsWith("tutorial/")) |
| 129 | return { kind: "doc", eyebrow: "tutorial · alchemy" }; |
| 130 | if (slug.startsWith("providers/")) |
| 131 | return { kind: "doc", eyebrow: "provider · alchemy" }; |
| 132 | if (slug.startsWith("compare/")) |
| 133 | return { kind: "doc", eyebrow: "compare · alchemy" }; |
| 134 | return { kind: "doc", eyebrow: "alchemy · documentation" }; |
| 135 | } |
| 136 | |
| 137 | /** Hash the actual card tree, including styles and embedded SVGs, plus renderer inputs. */ |
| 138 | export function createOgCacheKey( |
| 139 | fonts: Awaited<typeof brandFonts>, |
| 140 | options: object, |
| 141 | ) { |
| 142 | const shared = createHash("sha256").update(JSON.stringify(options)); |
| 143 | for (const { data, ...metadata } of fonts) { |
| 144 | shared.update(JSON.stringify(metadata)); |
| 145 | shared.update(new Uint8Array(data)); |
| 146 | } |
| 147 | return (card: ReactNode) => |
| 148 | shared.copy().update(renderToStaticMarkup(card)).digest("hex"); |
| 149 | } |
| 150 | |
| 151 | export const getStaticPaths: GetStaticPaths = async () => { |
| 152 | // `DOCS_FAST=1` (the `docs:check` build target) skips OG image generation — |
| 153 | // rendering PNGs is unnecessary for link checking. |
| 154 | if (process.env.DOCS_FAST) return []; |
| 155 | |
| 156 | const docs = await getCollection("docs"); |
| 157 | const docPaths = docs.map((entry: any) => { |
| 158 | const slug = (entry as { slug?: string; id?: string }).slug ?? entry.id; |
| 159 | const meta = classifyDoc(slug); |
| 160 | const data = entry.data as { |
| 161 | title?: string; |
| 162 | description?: string; |
| 163 | excerpt?: string; |
| 164 | date?: string | Date; |
| 165 | }; |
| 166 | return { |
| 167 | params: { slug }, |
| 168 | props: { |
| 169 | slug, |
| 170 | title: data.title ?? slug, |
| 171 | // Blog frontmatter uses `excerpt` (starlight-blog schema). Fall |
| 172 | // back to it so the OG card has body copy to fill the layout. |
| 173 | description: data.description ?? data.excerpt, |
| 174 | kind: meta.kind, |
| 175 | eyebrow: meta.eyebrow, |
| 176 | date: |
| 177 | data.date instanceof Date |
| 178 | ? data.date.toISOString().slice(0, 10) |
| 179 | : data.date, |
| 180 | } satisfies Entry, |
| 181 | }; |
| 182 | }); |
| 183 | |
| 184 | const marketingPaths = Object.entries(MARKETING_PAGES).map( |
| 185 | ([slug, meta]) => ({ |
| 186 | params: { slug }, |
| 187 | props: { |
| 188 | slug, |
| 189 | title: meta.title, |
| 190 | description: meta.description, |
| 191 | kind: "marketing" as const, |
| 192 | eyebrow: meta.eyebrow, |
| 193 | } satisfies Entry, |
| 194 | }), |
| 195 | ); |
| 196 | |
| 197 | // Virtual routes that emit og:image metas but aren't docs entries or |
| 198 | // hand-curated marketing pages: Starlight's 404 and starlight-blog's |
| 199 | // pagination indexes (/blog, /blog/2, …). The page count is estimated |
| 200 | // generously (starlight-blog paginates at ≥5 posts/page, so dividing |
| 201 | // by 5 can only overshoot); a surplus card is harmless, and the |
| 202 | // build-output og:image check fails loudly if a rendered page ever |
| 203 | // references a card this misses. |
| 204 | const blogPosts = docs.filter((entry: any) => |
| 205 | ((entry as { slug?: string; id?: string }).slug ?? entry.id).startsWith( |
| 206 | "blog/", |
| 207 | ), |
| 208 | ); |
| 209 | const blogDescription = "Release notes and posts from the alchemy team."; |
| 210 | const virtualPaths = [ |
| 211 | { |
| 212 | slug: "404", |
| 213 | title: "Page not found", |
| 214 | kind: "doc" as const, |
| 215 | eyebrow: "alchemy · documentation", |
| 216 | }, |
| 217 | ...Array.from( |
| 218 | { length: Math.max(1, Math.ceil(blogPosts.length / 5)) }, |
| 219 | (_, i) => ({ |
| 220 | slug: i === 0 ? "blog" : `blog/${i + 1}`, |
| 221 | title: "Blog", |
| 222 | description: blogDescription, |
| 223 | kind: "blog" as const, |
| 224 | eyebrow: "blog · alchemy.run", |
| 225 | }), |
| 226 | ), |
| 227 | ].map((props) => ({ |
| 228 | params: { slug: props.slug }, |
| 229 | props, |
| 230 | })); |
| 231 | |
| 232 | const paths = [...marketingPaths, ...docPaths, ...virtualPaths]; |
| 233 | setRenderEntries(paths.map(({ props }) => props)); |
| 234 | const cacheKey = createOgCacheKey(brandFonts, renderOptions); |
| 235 | return paths.map((path) => ({ |
| 236 | ...path, |
| 237 | cacheKey: cacheKey(OgCard(path.props)), |
| 238 | })); |
| 239 | }; |
| 240 | |
| 241 | export const GET: APIRoute = async ({ props }) => { |
| 242 | const entry = props as Entry; |
| 243 | const png = await renderWithLookahead(entry); |
| 244 | pendingRenders.delete(entry.slug); |
| 245 | return new Response(png, { |
| 246 | headers: { |
| 247 | "Content-Type": "image/webp", |
| 248 | }, |
| 249 | }); |
| 250 | }; |